| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441 |
- import { useMonaco } from '@monaco-editor/react'
- import { useLocalStorage } from '@uidotdev/usehooks'
- import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useParams } from 'common'
- import dayjs from 'dayjs'
- import type { editor } from 'monaco-editor'
- import { useRouter } from 'next/router'
- import { useEffect, useMemo, useRef, useState } from 'react'
- import { toast } from 'sonner'
- import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from 'ui'
- import {
- EXPLORER_DATEPICKER_HELPERS,
- getDefaultHelper,
- LOGS_LARGE_DATE_RANGE_DAYS_THRESHOLD,
- TEMPLATES,
- } from '@/components/interfaces/Settings/Logs/Logs.constants'
- import { DatePickerValue } from '@/components/interfaces/Settings/Logs/Logs.DatePickers'
- import { LogData, LogsWarning, LogTemplate } from '@/components/interfaces/Settings/Logs/Logs.types'
- import { UpdateSavedQueryModal } from '@/components/interfaces/Settings/Logs/Logs.UpdateSavedQueryModal'
- import {
- maybeShowUpgradePromptIfNotEntitled,
- useEditorHints,
- } from '@/components/interfaces/Settings/Logs/Logs.utils'
- import {
- buildLogQueryParams,
- resolveLogDateRange,
- } from '@/components/interfaces/Settings/Logs/logsDateRange'
- import LogsQueryPanel from '@/components/interfaces/Settings/Logs/LogsQueryPanel'
- import { LogTable } from '@/components/interfaces/Settings/Logs/LogTable'
- import UpgradePrompt from '@/components/interfaces/Settings/Logs/UpgradePrompt'
- import DefaultLayout from '@/components/layouts/DefaultLayout'
- import LogsLayout from '@/components/layouts/LogsLayout/LogsLayout'
- import CodeEditor from '@/components/ui/CodeEditor/CodeEditor'
- import LoadingOpacity from '@/components/ui/LoadingOpacity'
- import ShimmerLine from '@/components/ui/ShimmerLine'
- import { useContentQuery } from '@/data/content/content-query'
- import {
- UpsertContentPayload,
- useContentUpsertMutation,
- } from '@/data/content/content-upsert-mutation'
- import useLogsQuery from '@/hooks/analytics/useLogsQuery'
- import { useLogsUrlState } from '@/hooks/analytics/useLogsUrlState'
- import { useCustomContent } from '@/hooks/custom-content/useCustomContent'
- import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
- import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
- import { useUpgradePrompt } from '@/hooks/misc/useUpgradePrompt'
- import { uuidv4 } from '@/lib/helpers'
- import { useProfile } from '@/lib/profile'
- import { useTrack } from '@/lib/telemetry/track'
- import type { LogSqlSnippets, NextPageWithLayout } from '@/types'
- const LOCAL_PLACEHOLDER_QUERY =
- 'select\n timestamp, event_message, metadata\n from edge_logs limit 5'
- const PLATFORM_PLACEHOLDER_QUERY =
- 'select\n cast(timestamp as datetime) as timestamp,\n event_message, metadata \nfrom edge_logs \nlimit 5'
- export const LogsExplorerPage: NextPageWithLayout = () => {
- useEditorHints()
- const monaco = useMonaco()
- const router = useRouter()
- const { profile } = useProfile()
- const { ref, q, queryId } = useParams()
- const track = useTrack()
- const projectRef = ref as string
- const { logsShowMetadataIpTemplate } = useIsFeatureEnabled(['logs:show_metadata_ip_template'])
- const allTemplates = useMemo(() => {
- if (logsShowMetadataIpTemplate) return TEMPLATES
- else return TEMPLATES.filter((x) => x.label !== 'Metadata IP')
- }, [logsShowMetadataIpTemplate])
- const editorRef = useRef<editor.IStandaloneCodeEditor>(null)
- const [editorId] = useState<string>(uuidv4())
- const { search, setSearch, timestampStart, timestampEnd, setTimeRange } = useLogsUrlState()
- const defaultHelper = useMemo(() => getDefaultHelper(EXPLORER_DATEPICKER_HELPERS), [])
- const initialDatePickerValue = useMemo<DatePickerValue>(() => {
- if (timestampStart && timestampEnd) {
- return { from: timestampStart, to: timestampEnd, isHelper: false }
- }
- if (timestampStart) {
- return { from: timestampStart, to: timestampEnd || '', isHelper: false }
- }
- return {
- from: defaultHelper.calcFrom(),
- to: defaultHelper.calcTo(),
- isHelper: true,
- text: defaultHelper.text,
- }
- }, [timestampStart, timestampEnd, defaultHelper])
- const [datePickerValue, setDatePickerValue] = useState<DatePickerValue>(initialDatePickerValue)
- const { logsDefaultQuery } = useCustomContent(['logs:default_query'])
- const PLACEHOLDER_QUERY = IS_PLATFORM
- ? (logsDefaultQuery ?? PLATFORM_PLACEHOLDER_QUERY)
- : LOCAL_PLACEHOLDER_QUERY
- const [editorValue, setEditorValue] = useState<string>(PLACEHOLDER_QUERY)
- const [saveModalOpen, setSaveModalOpen] = useState<boolean>(false)
- const [warnings, setWarnings] = useState<LogsWarning[]>([])
- const [selectedLog, setSelectedLog] = useState<LogData | null>(null)
- const [recentLogs, setRecentLogs] = useLocalStorage<LogSqlSnippets.Content[]>(
- `project-content-${projectRef}-recent-log-sql`,
- []
- )
- const [useOtelEndpoint, setUseOtelEndpoint] = useLocalStorage<boolean>(
- `logs-explorer-use-otel-endpoint-${projectRef}`,
- false
- )
- const { getEntitlementNumericValue } = useCheckEntitlements('log.retention_days')
- const entitledToAuditLogDays = getEntitlementNumericValue()
- const { data: content } = useContentQuery({
- projectRef: ref,
- type: 'log_sql',
- })
- const query = content?.content.find((x) => x.id === queryId)
- const resolvedRange = useMemo(() => {
- if (datePickerValue.isHelper) {
- return resolveLogDateRange(datePickerValue)
- }
- if (timestampStart && timestampEnd) {
- return { from: timestampStart, to: timestampEnd }
- }
- return resolveLogDateRange(datePickerValue)
- }, [timestampStart, timestampEnd, datePickerValue])
- const {
- params,
- logData,
- error,
- isLoading: logsLoading,
- setParams,
- } = useLogsQuery(
- projectRef,
- {
- iso_timestamp_start: resolvedRange.from,
- iso_timestamp_end: resolvedRange.to,
- },
- true,
- { useOtel: useOtelEndpoint }
- )
- const results = logData
- const isLoading = logsLoading
- const { mutateAsync: upsertContent, isPending: isUpsertingContent } = useContentUpsertMutation({
- onError: (e) => {
- const error = e as { message: string }
- console.error(error)
- setSaveModalOpen(false)
- if (queryId) {
- toast.error(`Failed to update query: ${error.message}`)
- } else {
- toast.error(`Failed to save query: ${error.message}`)
- }
- },
- onSuccess: (_data, vars) => {
- setSaveModalOpen(false)
- if (queryId) {
- toast.success(`Updated "${vars.payload.name}" log query`)
- } else {
- toast.success(`Saved "${vars.payload.name}" log query`)
- }
- },
- })
- const addRecentLogSqlSnippet = (snippet: Partial<LogSqlSnippets.Content>) => {
- const defaults: LogSqlSnippets.Content = {
- schema_version: '1',
- sql: '',
- content_id: '',
- }
- setRecentLogs([...recentLogs, { ...defaults, ...snippet }])
- }
- const { showUpgradePrompt, setShowUpgradePrompt } = useUpgradePrompt(
- params.iso_timestamp_start as string
- )
- const onSelectTemplate = (template: LogTemplate) => {
- if (editorRef.current && monaco) {
- const editorModel = editorRef.current?.getModel()
- editorRef.current.pushUndoStop()
- editorRef.current.executeEdits(`insert-identifier`, [
- {
- text: template.searchString,
- range: editorModel?.getFullModelRange() ?? new monaco.Range(1, 1, 1, 1),
- },
- ])
- editorRef.current.pushUndoStop()
- editorRef.current.focus()
- }
- addRecentLogSqlSnippet({ sql: template.searchString })
- }
- const handleRun = (value?: string | React.MouseEvent) => {
- track('log_explorer_query_run_button_clicked', { is_saved_query: !!queryId })
- const query = typeof value === 'string' ? value || editorValue : editorValue
- const resolvedParams = buildLogQueryParams(datePickerValue, query)
- setParams((prev) => ({
- ...prev,
- sql: resolvedParams.sql,
- iso_timestamp_start: resolvedParams.from,
- iso_timestamp_end: resolvedParams.to,
- }))
- if (!datePickerValue.isHelper) {
- setTimeRange(resolvedParams.from, resolvedParams.to)
- } else {
- setTimeRange('', '')
- }
- setSearch(query)
- addRecentLogSqlSnippet({ sql: query })
- }
- const handleInsertSource = (source: string) => {
- if (editorRef.current && monaco) {
- const editorModel = editorRef.current?.getModel()
- const currentValue = editorRef.current.getValue()
- const index = currentValue.indexOf('from')
- const updatedValue =
- index < 0
- ? `${currentValue}${source}`
- : `${currentValue.substring(0, index + 4)} ${source} ${currentValue.substring(index + 5)}`
- editorRef.current.pushUndoStop()
- editorRef.current.executeEdits(`insert-identifier`, [
- {
- text: updatedValue,
- range: editorModel?.getFullModelRange() ?? new monaco.Range(1, 1, 1, 1),
- },
- ])
- editorRef.current.pushUndoStop()
- editorRef.current.focus()
- }
- }
- type SaveQueryFormValues = { name: string; description?: string }
- const handleCreateQuery = async (values: SaveQueryFormValues) => {
- if (!projectRef) return console.error('Project ref is required')
- if (!profile) return console.error('Profile is required')
- const id = uuidv4()
- const payload: UpsertContentPayload = {
- id,
- name: values.name,
- description: values.description || '',
- type: 'log_sql' as const,
- content: {
- content_id: editorId,
- sql: editorValue,
- schema_version: '1',
- favorite: false,
- } as LogSqlSnippets.Content,
- owner_id: profile.id,
- visibility: 'user' as const,
- }
- await upsertContent(
- { projectRef, payload },
- {
- onSuccess: () => router.push(`/project/${projectRef}/logs/explorer?queryId=${id}`),
- }
- )
- }
- async function handleOnSave() {
- if (!projectRef) return console.error('Project ref is required')
- // if we have a queryId, we are editing a saved query
- if (queryId && query) {
- await upsertContent({
- projectRef: projectRef!,
- payload: {
- ...query,
- content: { ...(query.content as LogSqlSnippets.Content), sql: editorValue },
- },
- })
- return
- }
- setSaveModalOpen(!saveModalOpen)
- }
- const handleDateChange = (value: DatePickerValue) => {
- setDatePickerValue(value)
- const resolvedRange = resolveLogDateRange(value)
- const shouldShowUpgradePrompt = maybeShowUpgradePromptIfNotEntitled(
- resolvedRange.from,
- entitledToAuditLogDays
- )
- if (shouldShowUpgradePrompt) {
- setShowUpgradePrompt(true)
- return
- }
- if (value.isHelper) {
- setTimeRange('', '')
- } else {
- setTimeRange(resolvedRange.from || '', resolvedRange.to || '')
- }
- setParams((prev) => ({
- ...prev,
- iso_timestamp_start: resolvedRange.from,
- iso_timestamp_end: resolvedRange.to,
- }))
- }
- useEffect(() => {
- if (search) {
- setEditorValue(search)
- } else if (q) {
- setEditorValue(q)
- setSearch(q)
- }
- }, [q, search, setSearch])
- useEffect(() => {
- // prevents overwriting when the user selects a helper.
- // without this, if the user selects "last 3 days" it would overwrite it with "last hour"
- // its the simplest solution I could come up with - jordi
- if (!initialDatePickerValue.isHelper) {
- setDatePickerValue(initialDatePickerValue)
- }
- }, [initialDatePickerValue])
- useEffect(() => {
- let newWarnings = []
- const start = timestampStart ? dayjs(timestampStart) : dayjs()
- const end = timestampEnd ? dayjs(timestampEnd) : dayjs()
- const daysDiff = Math.abs(start.diff(end, 'days'))
- if (daysDiff >= LOGS_LARGE_DATE_RANGE_DAYS_THRESHOLD) {
- newWarnings.push({
- text: 'Querying large date ranges can be slow. Consider selecting a smaller date range.',
- })
- }
- if (editorValue && !editorValue.toLowerCase().includes('limit')) {
- newWarnings.push({ text: 'When querying large date ranges, include a LIMIT clause.' })
- }
- setWarnings(newWarnings)
- }, [editorValue, timestampStart, timestampEnd])
- // Show the prompt on page load based on query params
- useEffect(() => {
- if (timestampStart) {
- const shouldShowUpgradePrompt = maybeShowUpgradePromptIfNotEntitled(
- timestampStart,
- entitledToAuditLogDays
- )
- if (shouldShowUpgradePrompt) {
- setShowUpgradePrompt(true)
- }
- }
- }, [timestampStart, entitledToAuditLogDays, setShowUpgradePrompt])
- return (
- <div className="w-full h-full mx-auto">
- <ResizablePanelGroup
- className="w-full h-full max-h-screen"
- orientation="vertical"
- autoSaveId={LOCAL_STORAGE_KEYS.LOG_EXPLORER_SPLIT_SIZE}
- >
- <ResizablePanel collapsible minSize="5">
- <LogsQueryPanel
- value={datePickerValue}
- onDateChange={handleDateChange}
- onSelectSource={handleInsertSource}
- templates={allTemplates.filter((template) => template.mode === 'custom')}
- onSelectTemplate={onSelectTemplate}
- warnings={warnings}
- useOtel={useOtelEndpoint}
- onUseOtelChange={setUseOtelEndpoint}
- />
- <ShimmerLine active={isLoading} />
- <CodeEditor
- // Ensure we reset the editor to the query content whenever the selected query changes
- key={queryId}
- id={editorId}
- editorRef={editorRef}
- language="pgsql"
- defaultValue={editorValue}
- onInputChange={(v) => setEditorValue(v || '')}
- actions={{ runQuery: { enabled: true, callback: handleRun } }}
- />
- </ResizablePanel>
- <ResizableHandle withHandle />
- <ResizablePanel collapsible minSize="5" className="overflow-auto">
- <LoadingOpacity active={isLoading}>
- <LogTable
- isSaving={isUpsertingContent}
- showHistogramToggle={false}
- onRun={handleRun}
- onSave={handleOnSave}
- hasEditorValue={Boolean(editorValue)}
- data={results}
- error={error}
- projectRef={projectRef}
- onSelectedLogChange={setSelectedLog}
- selectedLog={selectedLog || undefined}
- sqlQuery={editorValue}
- />
- <div className="flex flex-row justify-end mt-2">
- <UpgradePrompt show={showUpgradePrompt} setShowUpgradePrompt={setShowUpgradePrompt} />
- </div>
- </LoadingOpacity>
- </ResizablePanel>
- </ResizablePanelGroup>
- <UpdateSavedQueryModal
- header="Save log query"
- visible={saveModalOpen}
- initialValues={{ name: '', description: '' }}
- onCancel={() => {
- setSaveModalOpen(false)
- }}
- onSubmit={handleCreateQuery}
- />
- </div>
- )
- }
- LogsExplorerPage.getLayout = (page) => (
- <DefaultLayout>
- <LogsLayout title="Explorer">{page}</LogsLayout>
- </DefaultLayout>
- )
- export default LogsExplorerPage
|